/*
 * Copyright (c) 2023 fred
 * Licensed under the Apache License,Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 */
import { Connection } from '../Connection';
import { Validate } from '../helper/Validate';
import { Jsoup } from '../Jsoup';
import { Parser } from '../parser/Parser';
import { ParseSettings } from '../parser/ParseSettings';
import { Tag } from '../parser/Tag';
import { DocumentType } from './DocumentType';
import { Element } from './Element';
import { LeafNode } from './LeafNode';
import List from '@ohos.util.List';
// import { FormElement } from './FormElement';
import { EvaluatorTag } from '../select/Evaluator/EvaluatorTag';
import { StringUtil } from '../internal/StringUtil';
import buffer from '@ohos.buffer';
import { CoreCharset, Entities, EscapeMode } from './Entities';
import { OutputSettings } from './OutputSettings';
import { QuirksMode } from './QuirksMode';
import { Syntax } from './Syntax';

/**
 *  A HTML Document.
 */
export class Document extends Element {
  private _connection: Connection | null = null
  private _outputSettings = new OutputSettings()
  // the parser used to parse this document
  private _parser: Parser;
  private _quirksMode = QuirksMode.noQuirks
  private _location: string;
  private _updateMetaCharset = false

  constructor(baseUrl: string) {
    super(Tag.valueOf("#root", ParseSettings.htmlDefault), baseUrl);
    this._location = baseUrl
    this._parser = Parser.htmlParser()
  }

  public ownerDocument(): Document {
    let root = this.root();
    if (root instanceof Document) {
      return root
    }
    return null
  }
  /**
   Create a valid, empty shell of a document, suitable for adding more elements to.
   @param baseUri baseUri of document
   @return document with html, head, and body elements.
   */
  public static createShell(baseUri: string): Document {
    Validate.notNull(baseUri)
    let doc = new Document(baseUri)
    doc._parser = doc.getParser()
    let html = doc.appendElement("html")
    html.appendElement("head")
    html.appendElement("body")
    return doc
  }

  /**
   * Get the URL this Document was parsed from. If the starting URL is a redirect,
   * this will return the final URL from which the document was served from.
   * <p>Will return an empty string if the location is unknown (e.g. if parsed from a String).
   * @return location
   */
  public getLocation(): string {
    return this._location
  }

  /**
   Returns the Connection (Request/Response) object that was used to fetch this document, if any; otherwise, a new
   default Connection object. This can be used to continue a session, preserving settings and cookies, etc.
   @return the Connection (session) associated with this Document, or an empty one otherwise.
   @see Connection#newRequest()
   */
  public connection(): Connection {
    if (this._connection === undefined || this._connection === null) {
      return Jsoup.newSession()
    } else {
      return this._connection
    }
  }

  /**
   * Returns this Document's doctype.
   * @return document type, or null if not set
   */
  public documentType(): DocumentType | null {
    for (let node of this._childNodes) {
      if (node instanceof DocumentType) {
        return node as DocumentType
      } else if (!(node instanceof LeafNode)) {
        break
      }
    }
    return null
    // todo - add a set document type?
  }

  /**
   Find the root HTML element, or create it if it doesn't exist.
   @return the root HTML element.
   */
  private htmlEl(): Element {
    let el = this.firstElementChild()
    while (el !== undefined && el !== null) {
      if (el.normalName() === 'html') {
        return el
      }
      el = el.nextElementSibling()
    }
    return this.appendElement('html')
  }

  /**
   Get this document's {@code head} element.
   <p>
   As a side-effect, if this Document does not already have a HTML structure, it will be created. If you do not want
   that, use {@code #selectFirst("head")} instead.

   @return {@code head} element.
   */
  public head(): Element {
    let html = this.htmlEl()
    let el = html.firstElementChild()
    while (el !== undefined && el !== null) {
      if (el.normalName() === 'head') {
        return el
      }
      el = el.nextElementSibling()
    }
    return this.appendElement('head')
  }

  /**
   Get this document's {@code <body>} or {@code <frameset>} element.
   <p>
   As a <b>side-effect</b>, if this Document does not already have a HTML structure, it will be created with a {@code
   <body>} element. If you do not want that, use {@code #selectFirst("body")} instead.

   @return {@code body} element for documents with a {@code <body>}, a new {@code <body>} element if the document
   had no contents, or the outermost {@code <frameset> element} for frameset documents.
   */
  public body(): Element {
    let html = this.htmlEl()
    let el = html.firstElementChild()
    while (el !== undefined && el !== null) {
      if (el.normalName() === 'body' || el.normalName() === 'frameset') {
        return el
      }
      el = el.nextElementSibling()
    }
    return this.appendElement('body')
  }

  /**
   Get each of the {@code <form>} elements contained in this document.
   @return a List of FormElement objects, which will be empty if there are none.
   @see Elements#forms()
   @see FormElement#elements()
   @since 1.15.4
   */
  // public forms(): List<FormElement> {
  //   return this.select("form").forms()
  // }

  /**
   Selects the first {@link FormElement} in this document that matches the query. If none match, throws an
   {@link IllegalArgumentException}.
   @param cssQuery a {@link Selector} CSS query
   @return the first matching {@code <form>} element
   @throws IllegalArgumentException if no match is found
   @since 1.15.4
   */
  public expectForm(cssQuery: string): /*FormElement |*/ null {
    // let els = this.select(cssQuery)
    // for (let el of els) {
    //   if (el instanceof FormElement) {
    //     return el as FormElement
    //   }
    // }
    // Validate.fail(`No form elements matched the query ${cssQuery} in the document.`)
    return null
  }

  /**
   Get the string contents of the document's {@code title} element.
   @return Trimmed title, or empty string if none set.
   */
  public title(): string {
    // title is a preserve whitespace tag (for document output), but normalised here
    let titleEl = this.head().selectFirstEvaluator(Document._titleEval);
    return titleEl != null ? StringUtil.normaliseWhitespace(titleEl.text()).trim() : "";
  }

  private static readonly _titleEval = new EvaluatorTag("title");

  /**
   Set the document's {@code title} element. Updates the existing element, or adds {@code title} to {@code head} if
   not present
   @param title string to set as title
   */
  public setTitle(title: string) {
    Validate.notNull(title)
    let titleEl = this.head().selectFirstEvaluator(Document._titleEval);
    if (titleEl === undefined || titleEl === null) {
      titleEl = this.head().appendElement("title")
    }
    titleEl.setText(title)
  }

  /**
   Create a new Element, with this document's base uri. Does not make the new element a child of this document.
   @param tagName element tag name (e.g. {@code a})
   @return new element
   */
  public createElement(tagName: string): Element {
    Validate.notEmpty(tagName,'Document createElement tagName  must not be empty')
    return new Element(Tag.valueOf(tagName, ParseSettings.preserveCase), this.baseUri());
  }

  public outerHtml(): string {
    return super.getHtml()
  }

  /**
   Set the text of the {@code body} of this document. Any existing nodes within the body will be cleared.
   @param text unencoded text
   @return this document
   */
  public setText(text: string): Element {
    this.body().setText(text); // overridden to not nuke doc structure
    return this;
  }

  public nodeName(): string {
    return "#document"
  }

  /**
   * Sets the charset used in this document. This method is equivalent
   * to {@link OutputSettings#charset(java.nio.charset.Charset)
   * OutputSettings.charset(Charset)} but in addition it updates the
   * charset / encoding element within the document.
   *
   * <p>This enables
   * {@link #updateMetaCharsetElement(boolean) meta charset update}.</p>
   *
   * <p>If there's no element with charset / encoding information yet it will
   * be created. Obsolete charset / encoding definitions are removed!</p>
   *
   * <p><b>Elements used:</b></p>
   *
   * <ul>
   * <li><b>Html:</b> <i>&lt;meta charset="CHARSET"&gt;</i></li>
   * <li><b>Xml:</b> <i>&lt;?xml version="1.0" encoding="CHARSET"&gt;</i></li>
   * </ul>
   *
   * @param charset Charset
   *
   * @see #updateMetaCharsetElement(boolean)
   * @see OutputSettings#charset(java.nio.charset.Charset)
   */
  public setCharset(charset: string) {
    this.setUpdateMetaCharsetElement(true);
    this._outputSettings.setCharset(charset);
    this.ensureMetaCharsetElement();
  }

  /**
   * Returns the charset used in this document. This method is equivalent
   * to {@link OutputSettings#charset()}.
   *
   * @return Current Charset
   *
   * @see OutputSettings#charset()
   */
  public getCharset() {
    return this._outputSettings.getCharset()
  }

  /**
   * Sets whether the element with charset information in this document is
   * updated on changes through {@link #charset(java.nio.charset.Charset)
   * Document.charset(Charset)} or not.
   *
   * <p>If set to <tt>false</tt> <i>(default)</i> there are no elements
   * modified.</p>
   *
   * @param update If <tt>true</tt> the element updated on charset
   * changes, <tt>false</tt> if not
   *
   * @see #charset(java.nio.charset.Charset)
   */
  public setUpdateMetaCharsetElement(update: boolean) {
    this._updateMetaCharset = update
  }

  /**
   * Returns whether the element with charset information in this document is
   * updated on changes through {@link #charset(java.nio.charset.Charset)
   * Document.charset(Charset)} or not.
   *
   * @return Returns <tt>true</tt> if the element is updated on charset
   * changes, <tt>false</tt> if not
   */
  public getUpdateMetaCharsetElement(): boolean {
    return this._updateMetaCharset
  }

  public clone(): Document {
    let clone = super.clone() as Document
    clone._outputSettings = this._outputSettings.clone()
    return clone
  }

  public shallowClone(): Document {
    let clone = new Document(this.baseUri())
    if (this._attributes !== undefined && this._attributes !== null) {
      clone._attributes = this._attributes.clone()
    }
    clone._outputSettings = this._outputSettings.clone()
    return clone
  }

  /**
   * Ensures a meta charset (html) or xml declaration (xml) with the current
   * encoding used. This only applies with
   * {@link #updateMetaCharsetElement(boolean) updateMetaCharset} set to
   * <tt>true</tt>, otherwise this method does nothing.
   *
   * <ul>
   * <li>An existing element gets updated with the current charset</li>
   * <li>If there's no element yet it will be inserted</li>
   * <li>Obsolete elements are removed</li>
   * </ul>
   *
   * <p><b>Elements used:</b></p>
   *
   * <ul>
   * <li><b>Html:</b> <i>&lt;meta charset="CHARSET"&gt;</i></li>
   * <li><b>Xml:</b> <i>&lt;?xml version="1.0" encoding="CHARSET"&gt;</i></li>
   * </ul>
   */
  private ensureMetaCharsetElement() {
    if (!this._updateMetaCharset) {
      return
    }
    let syntax = this.getOutputSettings().getSyntax()
    if (syntax === Syntax.html) {
      let metaCharset = this.selectFirst("meta[charset]");
      if (metaCharset !== undefined && metaCharset !== null) {
        metaCharset.setAttr("charset", this.getCharset())
      } else {
        this.head().appendElement("meta").setAttr("charset", this.getCharset());
      }
      this.select("meta[name=charset]").removeElements(); // Remove obsolete elements
    } else if (syntax === Syntax.xml) {
      //todo
    }
  }

  /**
   * Get the document's current output settings.
   * @return the document's current output settings.
   */
  public getOutputSettings(): OutputSettings {
    return this._outputSettings
  }

  /**
   * Set the document's output settings.
   * @param outputSettings new output settings.
   * @return this document, for chaining.
   */
  public setOutputSettings(outputSettings: OutputSettings): Document {
    Validate.notNull(outputSettings)
    this._outputSettings = outputSettings
    return this
  }

  public setQuirksMode(quirksMode: QuirksMode): Document {
    this._quirksMode = quirksMode;
    return this;
  }

  public getQuirksMode(): QuirksMode {
    return this._quirksMode
  }

  /**
   * Get the parser that was used to parse this document.
   * @return the parser
   */
  public getParser(): Parser {
    return this._parser;
  }

  /**
   * Set the parser used to create this document. This parser is then used when further parsing within this document
   * is required.
   * @param parser the configured parser to use when further parsing is required for this document.
   * @return this document, for chaining.
   */
  public setParser(parser: Parser): Document {
    this._parser = parser;
    return this;
  }

  /**
   Set the Connection used to fetch this document. This Connection is used as a session object when further requests are
   made (e.g. when a form is submitted).

   @param connection to set
   @return this document, for chaining
   @see Connection#newRequest()
   @since 1.14.1
   */
  public setConnection(connection: Connection): Document {
    Validate.notNull(connection)
    this._connection = connection
    return this
  }
}


